哈囉,各位邦友們!
昨天我們把 Heroes 專案裡分散的 RxJS pipeline 收斂進 toSignal,讀寫邏輯簡潔不少。
但每次遇到非同步資料,還是得自建 loading/error/ready union 型別,並額外處理退訂與錯誤回復。
今天就在專案導入 resource() 與 rxResource(),透過resource() API 標準化這些繁瑣流程。
resource()/rxResource() 如何用宣告式 API 管理非同步狀態與取消控制。HeroService.getById() 支援 AbortSignal,讓框架能自動中斷請求。rxResource() 重構 HeroDetail,並更新模板讀取 loading/error/value 狀態。resource() 與 rxResource() 想解決什麼痛點?Signals 上線後,官方觀察到:
AbortController。resource() 專為非同步狀態打造,提供 value()、error()、isLoading() 等 signal 介面;rxResource() 則是針對 RxJS 情境的包裝,讓既有 Observable 服務不用改成 Promise,就能享受 resource() 的能力。
透過 DemoResourceComponent 來說名簡化後的概念:
import { Component, resource, signal } from '@angular/core';
@Component({
  selector: 'demo-resource',
  template: `
    @if (user.isLoading()) {
      <p>Loading user...</p>
    } @else if (user.error(); as err) {
      <p class="error">{{ err instanceof Error ? err.message : err }}</p>
    } @else if (user.value(); as detail) {
      <p>{{ detail.name }} ({{ detail.rank }})</p>
    }
    <button type="button" (click)="user.reload()">Reload</button>
  `,
})
export class DemoResourceComponent {
  private readonly selectedId = signal(1);
  readonly user = resource({
    params: () => this.selectedId(),
    loader: async ({ params, abortSignal }) => {
      const response = await fetch(`/api/heroes/${params}`, { signal: abortSignal });
      if (!response.ok) {
        throw new Error(`Failed to load hero #${params}`);
      }
      return (await response.json()) as { id: number; name: string; rank: string };
    },
    defaultValue: null,
  });
  select(id: number) {
    this.selectedId.set(id);
  }
}
說明重點:
params()(或 source)會觀察 signal,變化時觸發 loader()。loader() 會自帶 AbortSignal,路由切換時可自動取消請求。defaultValue、error()、isLoading() 提供統一的 UI 狀態讀取接口。reload() 讓我們能以指令式方式重新拉資料。HeroService.getById() 支援 AbortSignalrxResource() 在每次請求時都會提供 abortSignal,我們需要把它往 HttpClient 傳遞。
唯有 withFetch() 的 HttpClient 實作能支援 AbortSignal,專案已在 app.config.ts 啟用。
// src/app/hero.service.ts
getById(id: number, options?: { signal?: AbortSignal }): Observable<Hero> {
  const cached = this.heroesById().get(id);
  if (cached) {
    return of(cached);
  }
  const httpOptions: Record<string, unknown> = {};
  if (options?.signal) {
    (httpOptions as { signal?: AbortSignal }).signal = options.signal;
  }
  return this.http.get<Hero>(`${this.baseUrl}/${id}`, httpOptions).pipe(
    tap((hero) => {
      this.heroes.update((current) => {
        const exists = current.some((item) => item.id === hero.id);
        return exists ? current : [...current, hero];
      });
    })
  );
}
rxResource() 重構 HeroDetail取代 Day22 的 toSignal + switchMap pipeline,讓 rxResource() 主動管理 loading 與錯誤狀態。
// src/app/hero-detail/hero-detail.ts
import { Component, computed, inject, input } from '@angular/core';
import { NgOptimizedImage } from '@angular/common';
import { RouterModule } from '@angular/router';
import { rxResource } from '@angular/core/rxjs-interop';
import { Hero, HeroService } from '../hero.service';
import { LoadingSpinner } from '../ui/loading-spinner/loading-spinner';
import { MessageBanner } from '../ui/message-banner/message-banner';
@Component({
  selector: 'app-hero-detail',
  imports: [RouterModule, NgOptimizedImage, LoadingSpinner, MessageBanner],
  templateUrl: './hero-detail.html',
  styleUrl: './hero-detail.scss',
})
export class HeroDetail {
  readonly id = input.required<number>();
  private readonly heroService = inject(HeroService);
  readonly heroResource = rxResource<Hero | null, number>({
    params: () => this.id(),
    stream: ({ params, abortSignal }) =>
      this.heroService.getById(params, { signal: abortSignal }),
    defaultValue: null,
  });
  readonly hero = computed<Hero | null>(() => this.heroResource.value());
  readonly loading = computed(() => this.heroResource.isLoading());
  readonly errorMessage = computed(() => {
    const err = this.heroResource.error();
    if (!err) {
      return null;
    }
    return err instanceof Error ? err.message : String(err);
  });
  readonly avatarUrl = computed(() => {
    const detail = this.hero();
    if (!detail) {
      return null;
    }
    const seed = encodeURIComponent(detail.name);
    return `https://api.dicebear.com/7.x/bottts-neutral/png?seed=${seed}&size=320&background=%23eef3ff`;
  });
  reload() {
    this.heroResource.reload();
  }
}
要點說明:
rxResource 直接吃 Observable,不需再手動 firstValueFrom。stream 回傳 HeroService.getById(),同時把 abortSignal 往下傳。error() 不做預設處理,由 errorMessage 計算後提供給模板。reload() 可讓使用者重新整理細節資料。resource() 狀態hero-detail.html 只需觀察 loading()、errorMessage()、hero(),維持宣告式風格。
<!-- src/app/hero-detail/hero-detail.html -->
@if (loading()) {
  <app-loading-spinner label="Loading hero..."></app-loading-spinner>
} @else if (errorMessage(); as err) {
  <app-message-banner type="error">{{ err }}</app-message-banner>
} @else if (hero(); as detail) {
  <section class="detail">
    @if (avatarUrl(); as src) {
      <figure class="detail__media">
        <img
          [ngSrc]="src"
          width="320"
          height="320"
          [attr.fetchpriority]="detail.rank === 'S' ? 'high' : null"
          alt="{{ detail.name }} portrait" />
      </figure>
    }
    <header class="detail__header">
      <h2>{{ detail.name }}</h2>
      <small>Rank: {{ detail.rank ?? 'N/A' }}</small>
    </header>
    <a routerLink="/heroes">← Back to list</a>
  </section>
} @else {
  <p class="muted">No hero found.</p>
}
<button type="button" class="muted" (click)="reload()">重新整理</button>
resource() 會保留上一筆成功資料,因此在 loading 狀態下不會閃空白;若真的抓不到資料,就顯示提示文字。
今日小結:
今天透過 resource() API,將非同步狀態管理標準化,不再需要手刻 loading/error 處理邏輯。rxResource() 讓既有的 Observable 服務能無痛接入,HeroDetail 的狀態管理也因此大幅簡化。
參考資料: